第8章 对象、类与面向对象编程(1)

创建对象的方式通常是创建一个 Object 实例,然后再给它添加属性和方法:

let person = new Object();
person.name = "Nicholas";
person.age = 29;
person.job = "Software Engineer";
person.sayName = function() {
  console.log(this.name);
};

也可以通过字面量的形式创建

let person = {
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  sayName() {
    console.log(this.name);
  }
};

数据属性

数据属性有 4 个特性描述它们的行为

想要修改属性的默认特性,就必须使用 Object.defineProperty() 方法。该方法接收 3 个参数:要添加属性的对象、属性的名称和描述符对象。描述符对象上的属性可以包含:configurable、enumerable、writable 和 value。

let person = {};
Object.defineProperty(person, "name", {
  writable: false,
  value: "Nicholas"
});
console.log(person.name); // "Nicholas"
person.name = "Greg";
console.log(person.name); // "Nicholas"

通过上方代码创建的 person 对象,其 name 属性是不可修改的。在调用Object.defineProperty() 时,configurable、enumerable 和 writable 的值如果不指定,则都默认为 false。

访问器属性

访问器属性也有 4 个特性描述它们的行为

// 定义一个对象,包含伪私有成员year_和公共成员edition 
let book = {
  year_: 2017,
  edition: 1
};
// 为对象定义一个访问器
Object.defineProperty(book, "year", {
  get() {
    return this.year_;
  },
  set(newValue) {
    if (newValue > 2017) {
      this.year_ = newValue;
      this.edition += newValue - 2017;
    }
  } 
});
// 通过访问器访问对线的属性
book.year = 2018;
console.log(book.edition); // 2

获取函数和设置函数不一定都要定义,只定义获取函数意味着属性是只读的,尝试修改属性会被忽略

JS 提供了 Object.defineProperties() 方法,用于一次定义多个属性

let book = {};
Object.defineProperties(book, {
  year_: {
    value: 2017
  },
  edition: {
    value: 1
  },
  year: {
    get() {
      return this.year_;
    },
    set(newValue) {
      if (newValue > 2017) {
        this.year_ = newValue;
        this.edition += newValue - 2017;
      }
    }
  }
});           

使用 Object.getOwnPropertyDescriptor() 方法可以取得指定属性的属性描述符,返回值是一个对象,对于访问器属性包含 configurable、enumerable、get 和 set 属性,对于数据属性包含 configurable、enumerable、writable 和 value 属性。例如对于上面代码中定义的 book 对象

let descriptor = Object.getOwnPropertyDescriptor(book, "year_");
console.log(descriptor.value); // 2017
console.log(descriptor.configurable); // false
console.log(typeof descriptor.get); // "undefined"

let descriptor = Object.getOwnPropertyDescriptor(book, "year");
console.log(descriptor.value); // undefined
console.log(descriptor.enumerable); // false
console.log(typeof descriptor.get); // "function"

通过 Object.getOwnPropertyDescriptors() 方法可以一次性取得所有属性的属性描述符

ES6 为合并对象提供了 Object.assign() 方法,这个方法接收一个目标对象和一个或多个源对象作为参数,然后将每个源对象中可枚举( Object.propertyIsEnumerable() 返回 true)和自有(Object.hasOwnProperty() 返回 true)属性复制到目标对象。

let dest, src, result;
dest = {};
src = { id: 'src' };
result = Object.assign(dest, src);
console.log(dest === result); // true
console.log(dest !== src); // true
console.log(result); // { id: src }
console.log(dest); // { id: src }
// 多个源对象
dest = {};
result = Object.assign(dest, { a: 'foo' }, { b: 'bar' });
console.log(result); // { a: foo, b: bar }
// getter 和 setter 方法
dest = {
  set a(val) {
    console.log(`Invoked dest setter with param ${val}`);
  }
};
src = {
  get a() {
    console.log('Invoked src getter');
    return 'foo';
  } 
};
Object.assign(dest, src);
console.log(dest); // { set a(val) {...} }
// 可以从控制台中看到输出的对象,其中包含 set a: f a(val) 方法

Object.assign() 实际上对每个源对象执行的是浅拷贝。如果多个源对象都有相同的属性,则使用最后一个复制的值。不能在两个对象间转移 getter() 方法和 setter() 方法。如果赋值期间出错,则操作会中止并退出,同时抛出错误,但不会回滚到之前的对象状态。

let dest, src, result;
dest = {};
src = {
  a: 'foo',
  get b() {
    // Object.assign()在调用这个获取函数时会抛出错误
    throw new Error();
  },
  c: 'bar' 
};
try {
  Object.assign(dest, src);
} catch(e) {}
console.log(dest); // { a: foo }

对象间使用 Object.is() 方法进行相等判断,需要接收两个参数

console.log(Object.is(true, 1));  // false
console.log(Object.is({}, {})); // false
console.log(Object.is("2", 2)); // false
// 正确的 0、-0、+0 相等/不等判定 
console.log(Object.is(+0, -0)); // false
console.log(Object.is(+0, 0)); // true
console.log(Object.is(-0, 0)); // false
// 正确的 NaN 相等判定 
console.log(Object.is(NaN, NaN)); // true

几个好用的语法糖

let name = 'Matt';
// 属性名与变量名相同,可简写
let person = {
  name
};
console.log(person); // { name: 'Matt' }

// 对象通过字面量初始化时,可使用中括号语法声明计算属性
// 中括号中的内容被当做JS表达式进行求值后再显示
const nameKey = 'name';
const ageKey = 'age';
const jobKey = 'job';
let person = {
  [nameKey]: 'Matt',
  [ageKey]: 27,
  [jobKey]: 'Software engineer'
};
console.log(person);

// 可以将对象的方法名简写
let person = {
  sayName: function(name) {
    console.log(`My name is ${name}`);
  }
};
// 等价于
let person = {
  sayName(name) {
    console.log(`My name is ${name}`);
  }
}
person.sayName('Matt'); // My name is Matt
// 简写方法名与可计算属性键相互兼容
const methodKey = 'sayName';
let person = {
  [methodKey](name) {
    console.log(`My name is ${name}`);
  }
}
person.sayName('Matt'); // My name is Matt

对象解构

// 不使用对象解构的情况
let person = {
  name: 'Matt',
  age: 27 
};
let personName = person.name,
    personAge = person.age;
console.log(personName); // Matt
console.log(personAge);  // 27

// 使用对象解构的情况
let { name: personName, age: personAge } = person;
console.log(personName); // Matt
console.log(personAge);  // 27

// 对象解构配合简写语法
let { name, age } = person;
console.log(name);  // Matt
console.log(age);   // 27

// 如果引用的属性不存在,则该变量的值就是 undefined
let { name, job } = person;
console.log(name);  // Matt
console.log(job);   // undefined
// 可以在解构时定义默认值
let { name, job='Software engineer' } = person;
console.log(name); // Matt
console.log(job);  // Software engineer

// null 和 undefined 不能被解构,否则会抛出错误
let { _ } = null;           // TypeError
let { _ } = undefined;      // TypeError

// 解构并不要求变量必须在解构表达式中声明
// 如果是给事先声明的变量赋值,则赋值表达式 必须包含在一对括号中
let personName, personAge;
({name: personName, age: personAge} = person);
console.log(personName, personAge); // Matt, 27

创建对象的方式

  1. 工厂模式

    function createPerson(name, age, job) {
      let o = new Object();
      o.name = name;
      o.age = age;
      o.job = job;
      o.sayName = function() {
        console.log(this.name);
      };
      return o; 
    }
    let person1 = createPerson("Nicholas", 29, "Software Engineer"); 
    let person2 = createPerson("Greg", 27, "Doctor");
  2. 构造函数模式

    function Person(name, age, job){
      this.name = name;
      this.age = age;
      this.job = job;
      this.sayName = function() {
        console.log(this.name);
      }; 
    }
    let person1 = new Person("Nicholas", 29, "Software Engineer");
    let person2 = new Person("Greg", 27, "Doctor");

    Person() 构造函数代替了 createPerson() 工厂函数,区别在于:

    在实例化时,如果不想传参数,则构造函数后面的括号可加可不加

    function Person() {
      this.name = "Jake";
      this.sayName = function() {
        console.log(this.name);
      };
    }
    let person1 = new Person();
    let person2 = new Person;

构造函数与普通函数唯一的区别就是调用方式不同,任何函数只要使用 new 操作符调用就是构造函数,否则就是普通函数

// 作为构造函数
let person = new Person("Nicholas", 29, "Software Engineer");
person.sayName(); // "Nicholas"

// 作为函数调用
Person("Greg", 27, "Doctor"); // 对象被添加到了window上
window.sayName(); // "Greg"

// 在另一个对象的作用域中调用
let o = new Object();
Person.call(o, "Kristen", 25, "Nurse"); o.sayName(); // "Kristen"

通过构造函数创建的对象存在一个问题,即对象实例上的方法都是全新创建的

function Person(name, age, job){
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = function() {
    console.log(this.name);
  }; 
}
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
console.log(person1.sayName === person2.sayName); // false

解决方式可以把方法定义在构造函数外部

function Person(name, age, job){
  this.name = name;
  this.age = age;
  this.job = job;
  this.sayName = sayName;
}
function sayName() {
  console.log(this.name);
}; 
let person1 = new Person("Nicholas", 29, "Software Engineer");
let person2 = new Person("Greg", 27, "Doctor");
console.log(person1.sayName === person2.sayName); // true

但是这又引出了新的问题,如果这个对象需要多个方法,那么就要在全局作用域中定义多个函数。这会导致自定义类型引用的代码不能很好地聚集一起。因此又引出了第三种模式,原型模式

  1. 原型模式

    function Person() {}
    Person.prototype.name = "Nicholas";
    Person.prototype.age = 29;
    Person.prototype.job = "Software Engineer";
    Person.prototype.sayName = function() {
      console.log(this.name);
    };
    let person1 = new Person();
    let person2 = new Person();
    person1.sayName(); // "Nicholas"
    person2.sayName(); // "Nicholas"
    console.log(person1.sayName === person2.sayName); // true

每个函数都会创建一个 prototype 属性,这个属性是一个对象,包含应该由特定引用类型的实例共享的属性和方法。使用原型对象的好处是,在它上面定义的属性和方法可以被对象实例共享。

在通过对象访问属性时,会按照这个属性的名称开始搜索。搜索开始于对象实例本身。如果在这个实例上发现了给定的名称,则返回该名称对应的值。如果没有找到这个属性,则搜索会沿着指针进入原型对象,然后在原型对象上找到属性后,再返回对应的值。

如果在实例上添加了一个与原型对象中同名的属性,那就会在实例上创建这个属性,这个属性会覆盖原型对象上的属性。

function Person() {}
Person.prototype.name = "Nicholas";
Person.prototype.age = 29;
Person.prototype.job = "Software Engineer";
Person.prototype.sayName = function() {
  console.log(this.name);
};
let person1 = new Person();
let person2 = new Person();
person2.name = "Greg";
person1.sayName(); // "Nicholas",来自原型
person2.sayName(); // "Greg",来自实例

delete person2.name; // 删除实例上的 name 属性
person2.sayName(); // "Nicholas",来自原型

实例通过 hasOwnProperty() 方法可以确定某个属性是在实例上还是在原型对象上

let person1 = new Person();
let person2 = new Person();
person2.name = "Greg";

console.log(person2.hasOwnProperty("name")); // true
person2.sayName(); // "Greg",来自实例

delete person2.name; // 删除实例上的 name 属性
console.log(person2.hasOwnProperty("name")); // false
person2.sayName(); // "Nicholas",来自原型

使用 in 操作符可以通过对象访问指定属性,无论该属性是在实例上还是在原型上。存在属性则返回 true,不存在则返回 false

let person = new Person();

person.sayName(); // "Nicholas"
console.log(person.hasOwnProperty("name")); // false
console.log("name" in person); // true

person.name = "Greg";
person.sayName(); // "Greg"
console.log(person.hasOwnProperty("name")); // true 
console.log("name" in person); // true

原型模式之所以重要,不仅体现在自定义类型上,而且还因为它也是实现所有原生引用类型的模式。 所有原生引用类型的构造函数(包括 Object、Array、String 等)都在原型上定义了实例方法。比如,数组实例的 sort()方法就是 Array.prototype 上定义的,而字符串包装对象的 substring() 方法也是在 String.prototype 上定义的。

原型模式也不是没有问题,最明显的问题是所有实例默认都取得相同的属性值,但是原型的最主要问题源自它的共享特性。我们知道,原型上的所有属性是在实例间共享的,这对函数来说比较合适。另外包含原始值的属性也还好,可以通过在实例上添加同名属性来简单地遮蔽原型上的属性。真正的问题来自包含引用值的属性,例如:

function Person() {}
Person.prototype = {
  constructor: Person,
  name: "Nicholas",
  age: 29,
  job: "Software Engineer",
  friends: ["Shelby", "Court"],
  sayName() {
    console.log(this.name);
  } 
};
let person1 = new Person();
let person2 = new Person();

person1.friends.push("Van");
console.log(person1.friends);  // "Shelby,Court,Van"
console.log(person2.friends);  // "Shelby,Court,Van"
console.log(person1.friends === person2.friends);  // true

以上例子中,Person 原型中的 friends 属性维护着一个数组,向其中添加或者删除元素都会影响到所有通过 Person 原型创建的实例,因此在实际开发中通常不单独使用原型模式。